Offline
Loading...
select a GPUppy
Official GPUppy - read-only. Fork to edit.
No GPUppy selected
Pick one from the GPUppies tab or create a new custom shader to start editing.
Pro feature
Edit and create WGSL shaders
ready
Parameters
No GPUppy selected
Pick one from the GPUppies tab — its parameters appear here.
No editable GPUppy
Parameters open here for custom GPUppies — fork an official one to tweak its schema.

Editor

Preview

Library

When off, the library renders as one flat grid sorted by the category order. Filter chips always work.

Performance

Clears all AE caches (RAM + disk) on every shader compile. Without this, only RAM cache is cleared.
E 0 W 0 I 0 D 0 T 0
Import GLSL
Source
GLSL import
Import GLSL shader
Paste a single-pass fragment to convert it into WGSL for this GPUppy.
Renders with watermark Subscribe to Pro to remove.
Upgrade
Apply to current layer?
New GPUppy
Create and apply to current layer?
Delete from library?
Effect instances in projects will continue working.
Go Pro
    Shader Reference

    Shortcuts

    Tab / Shift+Tab      indent / outdent (4 spaces)
    Ctrl+/               toggle line comment
    Ctrl+S               save gpuppy
    Ctrl+I               trigger autocomplete (also auto after 2 chars or `u.` / `in.` / `Math.`)
    Ctrl+F / Ctrl+G      find / find next
    Ctrl+H               replace

    Ctrl works on both macOS and Windows. On macOS, Cmd variants are intercepted by After Effects (Cmd+S saves the project, Cmd+F opens AE's own find).

    Basics

    A custom gpuppy only needs helper functions plus one fragment entry point. GPUDog injects the vertex shader, uniforms, input texture, noise textures, and layer-param bindings before your code.

    @fragment
    fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> {
        let src = textureSample(input_texture, input_sampler, in.uv);
        return src;
    }

    Use in.uv for normalized layer coordinates, in.frag_coord for full-resolution pixel math, and u.* for built-in or user parameters.

    Sampling

    input_texture    texture_2d<f32>    the layer's pixels
    input_sampler    sampler            for textureSample(input_texture, ...)
    noise_2d         texture_2d<f32>    256x256 RGBA8 white noise
    noise_sampler    sampler            shared by noise_2d, noise_3d and layer params
    noise_3d         texture_3d<f32>    32x32x32 RGBA8 volume
    // sample the input at the current uv
    let src = textureSample(input_texture, input_sampler, in.uv);
    // use explicit mip level when derivatives are unstable
    let src = textureSampleLevel(input_texture, input_sampler, in.uv, 0.0);

    Params

    u.time        f32         seconds since start
    u.resolution  vec2<f32>   full-res output size in pixels
    u.ratio       f32         width / height (aspect)
    u.pixel_size  vec2<f32>   1.0 / resolution
    float     ->  f32
    int       ->  i32
    bool      ->  f32          stored as 0.0 / 1.0; use is_on(u.param)
    color     ->  vec4<f32>    rgba, each channel in [0..1]
    angle     ->  f32          radians (converted from degrees)
    point     ->  vec2<f32>    (x, y)
    popup     ->  i32          0-based index
    layer     ->  texture_2d + sampler

    Access a user param as u.<snake_case_name>. Checkbox params live as f32 because WGSL uniform layout has no bool. When a gpuppy declares a checkbox param, GPUDog also injects:

    is_on(v: f32) -> bool    // true when v > 0.5
    // sample a layer param named "mask"
    let mask_value = textureSample(mask, mask_sampler, in.uv).r;

    Coordinates

    in.uv             vec2<f32>   normalized [0, 1] over the layer
    in.frag_coord     vec2<f32>   fragment in full-res pixels (= uv * resolution)
    in.raw_position   vec4<f32>   raw rasterizer @builtin; advanced, reduced at Half/Third

    After Effects renders previews at Full, Half, or Third resolution for speed. GPUDog exposes full-resolution space, so dots, blocks, and scanlines stay stable across preview qualities. Prefer in.frag_coord, u.resolution, and u.pixel_size; the invariant frag_coord * pixel_size == frag_coord / resolution == uv holds everywhere.

    // snap to a pixel-sized cell grid (preview-stable)
    let cell    = 8.0;                                  // size in screen pixels
    let snapped = floor(in.frag_coord / cell) * cell + cell * 0.5;
    let cell_uv = snapped * u.pixel_size;               // == snapped / u.resolution
    // aspect-correct circle in pixel space
    let center = u.resolution * 0.5;
    let d      = length(in.frag_coord - center);
    let mask   = smoothstep(50.0, 48.0, d);             // 50 px radius

    uv-space ↔ p-space. uv-space is [0, 1], the raw layer texture coordinates used for textureSample and any distortion you feed back into it. p-space is [-ratio..ratio, -1..1], centred at origin and aspect-corrected so circles stay circles — preferred for SDF math and directional effects. Two transforms below convert positions (use ±0.5 centre offset), two convert displacement vectors (scale only, no centre offset because translation doesn't apply to vectors). All four use the same factor 2 · vec2(u.ratio, 1).

    // uv → p (position, aspect-correct)
    // uv ∈ [0,1] → p ∈ [-ratio..ratio, -1..1]. Centre at origin, scale ×2,
    // stretch x by aspect so circles stay circles.
    let p = (in.uv - 0.5) * 2.0 * vec2<f32>(u.ratio, 1.0);
    // p → uv (position, inverse)
    // p ∈ [-ratio..ratio, -1..1] → uv ∈ [0,1]. Inverse of the snippet above.
    let uv = p / (2.0 * vec2<f32>(u.ratio, 1.0)) + 0.5;
    // p-space displacement → uv-space displacement
    // Vector — only scale, no centre offset (the 0.5 / -0.5 is a point
    // translation, irrelevant for vectors). Used with textureSample as
    // `in.uv + disp_uv`.
    let disp_uv = disp_p / (2.0 * vec2<f32>(u.ratio, 1.0));
    // uv-space displacement → p-space displacement
    // Inverse of the snippet above — scale only, no centre offset.
    let disp_p = disp_uv * 2.0 * vec2<f32>(u.ratio, 1.0);
    // 2D rotation matrix helper
    fn rot2(a: f32) -> mat2x2<f32> {
        let c = cos(a);
        let s = sin(a);
        return mat2x2<f32>(c, s, -s, c);
    }
    // unit direction vector from an angle param (radians)
    // angle params arrive as radians (host converts degrees → radians).
    // dir is a unit vector pointing along the angle — rotates as the
    // user drags the Angle control in AE.
    let dir = vec2<f32>(cos(u.angle), sin(u.angle));
    // perpendicular vector (+90°, no trig)
    // Rotate dir by +90° by swapping components and flipping one sign.
    // (dir, tangent) form an orthonormal basis aligned with the angle —
    // one axis points "across" the line, the other "along" it.
    // For -90° use vec2<f32>(dir.y, -dir.x).
    let tangent = vec2<f32>(-dir.y, dir.x);
    // project a pixel onto the (dir, tangent) basis
    // dot(a, b) with a unit-length b is the signed projection of a onto b.
    //   n — normal coordinate (across the line, along dir).
    //   t — tangent coordinate (along the line, along tangent).
    // Any "directional" effect splits into a function of n (the cut)
    // and t (modulation along the cut).
    let pos_px    = in.uv / u.pixel_size;
    let center_px = vec2<f32>(0.5) / u.pixel_size;
    let rel       = pos_px - center_px;
    let n = dot(rel, dir);            // normal coordinate
    let t = dot(rel, tangent);        // tangent coordinate
    // signed-distance band of half-width w with smooth falloff
    // nc = signed distance to the band centre along the normal axis.
    // d ramps from 0 inside the core to positive outside; ramp adds a
    // smoothstep falloff of `sm` pixels so the edge isn't hard. Use
    // sign(nc) to push pixels outward along dir (inward by flipping it).
    let nc   = n - u.offset;
    let d    = abs(nc) - w * 0.5 + sm * 0.5;
    let ramp = max(d, 0.0) * smoothstep(0.0, sm, d);
    // wavy edge: bend the band by a function of the tangent coord t
    // edge_shape offsets n by something that depends on t, so the
    // "nc = 0" contour bends along the line instead of being straight.
    // Swap sin() for tri() for zig-zag, or a noise sample for organic.
    let edge_shape = sin(t * 0.03 + u.time) * 50.0;
    let nc         = n - u.offset - edge_shape;
    // triangle wave for zig-zag edges
    fn tri(x: f32) -> f32 {
        let f = fract(x);
        return abs(f * 2.0 - 1.0) * 2.0 - 1.0;
    }

    Constants

    GPUDog injects a Math namespace into every shader — accessed AE-expressions-style as Math.PI, Math.TAU, Math.DEG2RAD, etc. It's a module-scope const struct; field access is folded at compile time, so there's no runtime cost. Built-in functions (cos, sin, pow, sqrt, mix, clamp, …) are plain global names in WGSL — call them without a namespace: cos(angle), not Math.cos(angle).

    // Math namespace — already injected, use directly
    // Available fields on `Math` (auto-injected, do not redeclare):
    //   Math.PI        3.14159265358979
    //   Math.TAU       6.28318530717958   // 2 * PI — one full turn
    //   Math.HALF_PI   1.57079632679490   // PI / 2
    //   Math.INV_PI    0.31830988618379   // 1 / PI
    //   Math.INV_TAU   0.15915494309189   // 1 / TAU
    //   Math.SQRT_2    1.41421356237310
    //   Math.SQRT_3    1.73205080756888
    //   Math.PHI       1.61803398874989   // golden ratio
    //   Math.E         2.71828182845905   // Euler's number
    //   Math.DEG2RAD   0.01745329251994   // PI / 180
    //   Math.RAD2DEG   57.2957795130823   // 180 / PI
    //   Math.EPS       1e-6               // safe denominator floor
    //   Math.RIGHT     vec2( 1,  0)
    //   Math.LEFT      vec2(-1,  0)
    //   Math.UP        vec2( 0, -1)       // uv-y grows downward
    //   Math.DOWN      vec2( 0,  1)
    //   Math.CENTER    vec2(0.5, 0.5)
    //   Math.LUMA_709  vec3(0.2126, 0.7152, 0.0722)
    //   Math.LUMA_601  vec3(0.299, 0.587, 0.114)
    
    let theta = 45.0 * Math.DEG2RAD;       // literal degrees → radians
    let turn  = u.time * Math.TAU;         // one full rotation per second
    // epsilon — guard against div-by-zero / NaN
    // usage: keep denominators positive, avoid NaN on degenerate inputs.
    let safe_len = max(length(v), Math.EPS);
    let unit     = v / safe_len;
    // Rec.709 luma helper
    fn luma709(c: vec3<f32>) -> f32 {
        return dot(c, Math.LUMA_709);
    }
    // Rec.601 luma helper
    fn luma601(c: vec3<f32>) -> f32 {
        return dot(c, Math.LUMA_601);
    }
    // luma usage
    let y = luma709(src.rgb);
    // cardinal direction vectors (axis-aligned)
    let uv_from_center = in.uv - Math.CENTER;
    let one_px_right   = in.uv + Math.RIGHT * u.pixel_size.x;
    let one_px_up      = in.uv + Math.UP * u.pixel_size.y;

    Noise

    noise_2d is a repeatable 256x256 RGBA texture of raw white noise — each texel is independent, so direct samples look grainy with hard texel edges. For smooth Perlin-style noise, wrap a sample in a quintic/hermite curve (smooth_noise below). For complexity, stack octaves via FBM. noise_3d is a 32³ volume; use time as z for smooth flicker-free animation.

    // white noise seeded by uv and time (2D, grainy by design)
    let seed = in.uv + vec2<f32>(u.time * 0.1, 0.0);
    let n    = textureSampleLevel(noise_2d, noise_sampler, seed, 0.0).r;
    // smoothly animated volumetric noise (3D, z = time)
    let p = vec3<f32>(in.uv * 4.0, u.time * 0.2);
    let v = textureSampleLevel(noise_3d, noise_sampler, p, 0.0).r;
    // smooth value noise (Perlin-look from white-noise texture)
    // Trick: push the fractional part through a hermite S-curve BEFORE
    // the texture lookup. The hardware bilinear filter then blends the
    // 4 cell corners with a smooth cubic instead of a linear ramp →
    // no diamond/triangle artefacts on the cell edges.
    // p is in cell units; one cell ≈ one screen feature.
    // Call as: smooth_noise(in.uv * 6.0 + vec2f(u.time, 0.0))
    fn smooth_noise(p: vec2<f32>) -> f32 {
        let i = floor(p);
        let f = fract(p);
        let h = f * f * (3.0 - 2.0 * f);                       // hermite
        let nuv = (i + h + 0.5) / 256.0;                       // 256² tex
        return textureSampleLevel(noise_2d, noise_sampler, nuv, 0.0).r;
    }
    // fbm with complexity (octaves) — stacks smooth_noise at 2× freq
    // `octaves` is the complexity dial — expose it as an int param
    // (e.g. "Detail" 1..8). Each octave doubles frequency and halves
    // amplitude; gain < 0.5 keeps the result tame, > 0.5 adds bite.
    // Needs `smooth_noise` from the previous snippet.
    // Call as: fbm(in.uv * 4.0, clamp(u.detail, 1, 8), 0.5)
    fn fbm(p: vec2<f32>, octaves: i32, gain: f32) -> f32 {
        var sum  = 0.0;
        var amp  = 0.5;
        var norm = 0.0;
        var q    = p;
        for (var i = 0; i < octaves; i = i + 1) {
            sum  = sum + amp * smooth_noise(q);
            norm = norm + amp;
            q    = q * 2.07 + vec2<f32>(13.7, 7.13);            // de-aligns octaves
            amp  = amp * gain;
        }
        return sum / norm;
    }
    // fbm RGBA (raw bilinear) — grainy at high freqs, fast
    // Cheaper, but cell edges show through at high octaves (1 sample
    // per octave, hardware linear only). Use smooth_noise + fbm above
    // when artefacts matter.
    fn fbm_rgba(p: vec2<f32>) -> vec4<f32> {
        var f = vec4<f32>(0.0);
        var amp = 0.5;
        var q = p;
        var norm = 0.0;
        for (var i = 0; i < 6; i = i + 1) {
            f = f + amp * textureSampleLevel(noise_2d, noise_sampler, q, 0.0);
            q = q * 2.07 + vec2<f32>(13.7, 7.13);
            norm = norm + amp;
            amp = amp * 0.5;
        }
        return f / norm;
    }

    Color

    // Rec.709 luma from RGB
    fn luma709(c: vec3<f32>) -> f32 {
        return dot(c, Math.LUMA_709);
    }
    // saturation helper
    fn adjust_saturation(c: vec3<f32>, amount: f32) -> vec3<f32> {
        let y = luma709(c);
        return mix(vec3<f32>(y), c, amount);
    }
    // saturation usage
    let rgb = adjust_saturation(src.rgb, 1.4);
    // brightness + contrast helper (50 = neutral)
    fn brightness_contrast(v: f32, brightness: f32, contrast: f32) -> f32 {
        let cn = contrast * 0.02;              // 0..100 -> 0..2, 50 = 1
        let br = (brightness - 50.0) * 0.02;   // 0..100 -> -1..+1, 50 = 0
        return saturate((v - 0.5) * cn + 0.5 + br);
    }
    // brightness + contrast usage
    let y = brightness_contrast(luma709(src.rgb), u.brightness, u.contrast);
    // cosine-based palette (Iñigo Quilez)
    // Smooth, periodic colour ramp. R/G/B are cosines with offset phases:
    //   `hue`    rotates the whole palette (0..1 cycles through hues)
    //   `spread` controls channel separation — 0 = grayscale,
    //            1 = full rainbow (R/G/B at 120° / 240°)
    // Returns rgb in [0, 1]; pass output through pow for gamma if needed.
    // Call as: layer_col = cos_palette(t * u.palette_cycles * 0.01, -u.hue_shift, u.palette_spread);
    fn cos_palette(t: f32, hue: f32, spread: f32) -> vec3<f32> {
        let d = vec3<f32>(
            hue,
            hue + spread * (Math.TAU / 3.0),
            hue + spread * (Math.TAU * 2.0 / 3.0),
        );
        return 0.5 + 0.5 * cos(t * Math.TAU + d);
    }
    // HSV → RGB (branchless, no conditionals)
    // c.x = hue (0..1, wraps), c.y = saturation (0..1), c.z = value (0..1).
    // Branchless implementation — no warp divergence, runs at constant cost
    // regardless of input. Sami Sarpola / Sam Hocevar trick.
    // Call as: hsv2rgb(vec3f(u.time * 0.1, 0.8, 1.0))
    fn hsv2rgb(c: vec3<f32>) -> vec3<f32> {
        let K = vec4<f32>(1.0, 2.0 / 3.0, 1.0 / 3.0, 3.0);
        let p = abs(fract(c.xxx + K.xyz) * 6.0 - K.www);
        return c.z * mix(K.xxx, clamp(p - K.xxx, vec3<f32>(0.0), vec3<f32>(1.0)), c.y);
    }
    // spectral RGB reconstruction along an offset
    const WL_RED:  f32 = 750.0;
    const WL_BLUE: f32 = 350.0;
    const SPECTRAL_SAMPLES: i32 = 16;
    
    // vec4 so alpha is preserved — Rgba16Float pipeline expects rgba out.
    fn spectral_sample(uv: vec2<f32>, offset: vec2<f32>, dispersion: f32) -> vec4<f32> {
        var accum = vec4<f32>(0.0);
        var w_sum = vec4<f32>(0.0);
    
        for (var i = 0; i < SPECTRAL_SAMPLES; i = i + 1) {
            let t = f32(i) / f32(SPECTRAL_SAMPLES - 1);
            let wl = mix(WL_RED, WL_BLUE, t);
            let weight = clamp(vec3<f32>(
                -22.031 + 0.06936 * wl - 0.00005115 * wl * wl,
                -26.168 + 0.10105 * wl - 0.00009368 * wl * wl,
                -36.855 + 0.17063 * wl - 0.00019177 * wl * wl,
            ), vec3<f32>(0.0), vec3<f32>(1.0));
            let weight4 = vec4<f32>(weight, 1.0);   // alpha summed with unit weight
    
            let wl_scale  = 1.0 + dispersion * (t - 0.5);
            let sample_uv = uv + offset * wl_scale;
            accum = accum + textureSample(input_texture, input_sampler, sample_uv) * weight4;
            w_sum = w_sum + weight4;
        }
        return accum / w_sum;
    }

    Text & Fonts

    GPUDog ships a built-in MSDF font atlas: four fonts with Latin + Cyrillic + Japanese kana coverage, plus a shared box-drawing / blocks / geometric symbol set (U+2500..U+25FF). Glyphs stay crisp at any scale.

    font_id 0   Tiny5            pixel, tiny
    font_id 1   Pixelify Sans    pixel
    font_id 2   IBM Plex Mono    clean monospace
    font_id 3   Six Caps         condensed caps display (Latin only; Cyrillic falls back to Latin twins)
    
    kana        fonts 0/1 -> DotGothic16 (pixel)   font 2 -> Kosugi Maru (rounded)
    ascii_glyph(font_id, code, cell_uv) -> f32     // AA coverage of one glyph, cell_uv in [0,1]
    glyph_at(code, p, org, sz, font_id) -> f32     // one glyph inside a pixel rect
    text_cell(p, origin, char_size,
              spacing_pct, line_spacing_pct)
              -> TextCell { col, row, uv, mask }   // uniform-grid layout, glyph at natural size
    print_value(value, p, org, sz,
                int_digits, frac_digits, font_id)  // debug numbers on screen

    #text blocks turn raw text lines into lookup functions at compile time — no manual codepoint arrays, no hand-counted offsets. Characters outside the atlas are a compile error. Each block generates NAME(line, col) (codepoint, 0x20 outside), NAME_len(line), NAME_LINES and NAME_MAXLEN. For one-line charset ramps there is the #chars one-liner: NAME(i) (clamped index), NAME_t(t) (normalized [0,1) → glyph) and NAME_LEN.

    // #chars — one-line charset ramp (ASCII-art brightness sets)
    #chars RAMP " .:-=+*#%@"      // quotes keep the leading space
    #chars MATRIX "'./ノハメラマ1"
    
    // light -> dense by luminance:
    let code = RAMP_t(lum);        // t in [0,1) -> glyph codepoint
    let g = ascii_glyph(u.font_id, code, cuv);
    // minimal text line — #text block + text_cell
    #text GREETING
    HELLO ● WORLD
    #endtext
    
    @fragment
    fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> {
        let pos = u.resolution * 0.5;
        let tc = text_cell(in.frag_coord, pos, 48.0, 110.0, 100.0);
        let g = ascii_glyph(2, GREETING(0, tc.col), tc.uv)
              * tc.mask * f32(tc.row == 0);
        let src = textureSample(input_texture, input_sampler, in.uv);
        return mix(src, vec4<f32>(1.0), g);
    }
    // typewriter reveal — animate per column with tc.col
    let typed = i32(u.time * 12.0);   // characters per second
    let visible = f32(tc.col < min(typed, GREETING_len(0)));
    let g = ascii_glyph(2, GREETING(0, tc.col), tc.uv)
          * tc.mask * visible * f32(tc.row == 0);
    // Japanese — kana flavor follows the font (0/1 pixel, 2 rounded)
    #text JP
    ジーピーユードッグ
    ひらがな も つかえます
    #endtext
    // Cyrillic + one line per popup choice (Text popup pattern)
    #text SETS
    ВЫЙДИ ИЗ GPUТРИКС
    GPU ● ШЕЙДЕРЫ ● МАГИЯ
    #endtext
    
    // let line = clamp(u.text, 0, SETS_LINES - 1);  // popup param "text"
    // let code = SETS(line, tc.col);
    // symbols — box-drawing, blocks, geometric (shared across all fonts)
    #text FRAME
    ┌──────────┐
    │ ░▒▓█ ●◆▲ │
    └──────────┘
    #endtext
    // character consts — single glyphs without a #text block
    var word = array<i32, 4>(_G, _D, _O, _G);   // _A.._z, _0.._9, _SP, _DOT...
    let dot = ascii_glyph(1, _CIRCLE, cuv);      // ● — curated symbol consts
    let any = ascii_glyph(1, 0x25D0, cuv);       // ◐ — or any codepoint directly
    let cyr = ascii_glyph(0, c_YA, cuv);         // Я — Cyrillic c_* consts
    // debug — print a value on screen
    let dbg = print_value(u.time, in.frag_coord,
        vec2<f32>(20.0), vec2<f32>(14.0, 20.0), 4, 2, 2);
    return max(src, vec4<f32>(dbg));

    SDF

    2D shape library below: WGSL ports of Inigo Quilez's exact 2D SDFs (iquilezles.org/articles/distfunctions2d). All signed (negative inside), centered at origin, y up. WGSL note: function params are immutable — copy to var p = p_in; first; GLSL's mod() is floor-based, WGSL % truncates, so use x - y*floor(x/y) for angles.

    Transforming a shape — apply the transform to the sample point, not the distance (rot2 is in the Coordinates chapter). Rotation is an isometry so distance is preserved; for a uniform scale divide the result back to keep a true SDF (so smoothstep AA and outlines stay correct); non-uniform scale only approximates the distance.

    // rotate a shape before its SDF (rotate the point)
    let p = rot2(u.angle) * q;     // rotating the POINT spins the shape
    let d = sd_pentagram(p, 1.0);  // rotation preserves distance
    // uniform-scale a shape, staying a true SDF
    let s = 1.3;                      // divide the point, multiply the result
    let d = sd_hexagon(q / s, 0.8) * s;
    // equilateral triangle — exact (iq)
    fn sd_equilateral_triangle(p_in: vec2f, r: f32) -> f32 {
        let k = sqrt(3.0);
        var p = p_in;
        p.x = abs(p.x) - r;
        p.y = p.y + r / k;
        if (p.x + k * p.y > 0.0) {
            p = vec2f(p.x - k * p.y, -k * p.x - p.y) / 2.0;
        }
        p.x -= clamp(p.x, -2.0 * r, 0.0);
        return -length(p) * sign(p.y);
    }
    // regular pentagon — exact (iq)
    fn sd_pentagon(p_in: vec2f, r: f32) -> f32 {
        let k = vec3f(0.809016994, 0.587785252, 0.726542528);
        var p = p_in;
        p.x = abs(p.x);
        p -= 2.0 * min(dot(vec2f(-k.x, k.y), p), 0.0) * vec2f(-k.x, k.y);
        p -= 2.0 * min(dot(vec2f( k.x, k.y), p), 0.0) * vec2f( k.x, k.y);
        p -= vec2f(clamp(p.x, -r * k.z, r * k.z), r);
        return length(p) * sign(p.y);
    }
    // regular hexagon — exact (iq)
    fn sd_hexagon(p_in: vec2f, r: f32) -> f32 {
        let k = vec3f(-0.866025404, 0.5, 0.577350269);
        var p = abs(p_in);
        p -= 2.0 * min(dot(k.xy, p), 0.0) * k.xy;
        p -= vec2f(clamp(p.x, -k.z * r, k.z * r), r);
        return length(p) * sign(p.y);
    }
    // regular octagon — exact (iq)
    // https://www.shadertoy.com/view/llGfDG
    fn sd_octagon(p_in: vec2f, r: f32) -> f32 {
        let k = vec3f(-0.9238795325, 0.3826834323, 0.4142135623);
        var p = abs(p_in);
        p -= 2.0 * min(dot(vec2f( k.x, k.y), p), 0.0) * vec2f( k.x, k.y);
        p -= 2.0 * min(dot(vec2f(-k.x, k.y), p), 0.0) * vec2f(-k.x, k.y);
        p -= vec2f(clamp(p.x, -k.z * r, k.z * r), r);
        return length(p) * sign(p.y);
    }
    // pentagram (5-point star) — exact (iq)
    fn sd_pentagram(p_in: vec2f, r: f32) -> f32 {
        let k1x = 0.809016994; // cos(pi/5)
        let k2x = 0.309016994; // sin(pi/10)
        let k1y = 0.587785252; // sin(pi/5)
        let k2y = 0.951056516; // cos(pi/10)
        let k1z = 0.726542528; // tan(pi/5)
        let v1 = vec2f( k1x, -k1y);
        let v2 = vec2f(-k1x, -k1y);
        let v3 = vec2f( k2x, -k2y);
        var p = p_in;
        p.x = abs(p.x);
        p -= 2.0 * max(dot(v1, p), 0.0) * v1;
        p -= 2.0 * max(dot(v2, p), 0.0) * v2;
        p.x = abs(p.x);
        p.y -= r;
        return length(p - v3 * clamp(dot(p, v3), 0.0, k1z * r))
             * sign(p.y * v3.x - p.x * v3.y);
    }
    // N-pointed star — exact (iq); m in [2..n], m=n → polygon
    fn sd_star(p_in: vec2f, r: f32, n: i32, m: f32) -> f32 {
        let an = 3.141593 / f32(n);
        let en = 3.141593 / m;
        let acs = vec2f(cos(an), sin(an));
        let ecs = vec2f(cos(en), sin(en)); // ecs = vec2f(0,1) for a regular polygon
        var p = p_in;
        // GLSL mod() is floor-based; WGSL % truncates -> do it by hand.
        let a2 = 2.0 * an;
        let at = atan2(p.x, p.y);
        let bn = (at - a2 * floor(at / a2)) - an;
        p = length(p) * vec2f(cos(bn), abs(sin(bn)));
        p -= r * acs;
        p += ecs * clamp(-dot(p, ecs), 0.0, r * acs.y / ecs.y);
        return length(p) * sign(p.x);
    }
    // heart — exact (iq); x in [-0.6,0.6], y in [0,1], lobes up
    fn dot2(v: vec2f) -> f32 { return dot(v, v); }
    
    // https://www.shadertoy.com/view/3tyBzV
    fn sd_heart(p_in: vec2f) -> f32 {
        var p = p_in;
        p.x = abs(p.x);
        if (p.y + p.x > 1.0) {
            return sqrt(dot2(p - vec2f(0.25, 0.75))) - sqrt(2.0) / 4.0;
        }
        return sqrt(min(dot2(p - vec2f(0.0, 1.0)),
                        dot2(p - 0.5 * max(p.x + p.y, 0.0)))) * sign(p.x - p.y);
    }
    // cross (plus) — exact (iq)
    // b.x = arm length, b.y = arm thickness, r rounds the corners.
    // https://www.shadertoy.com/view/XtGfzw
    fn sd_cross(p_in: vec2f, b: vec2f, r: f32) -> f32 {
        var p = abs(p_in);
        p = select(p, p.yx, p.y > p.x);
        let q = p - b;
        let k = max(q.y, q.x);
        let w = select(vec2f(b.y - p.x, -k), q, k > 0.0);
        return sign(k) * length(max(w, vec2f(0.0))) + r;
    }
    // rounded X — exact (iq)
    // https://www.shadertoy.com/view/3dKSDc
    fn sd_rounded_x(p_in: vec2f, w: f32, r: f32) -> f32 {
        let p = abs(p_in);
        return length(p - min(p.x + p.y, w) * 0.5) - r;
    }
    // signed circle distance in pixel space
    fn sd_circle(p: vec2<f32>, r: f32) -> f32 {
        return length(p) - r;
    }
    
    let d = sd_circle(in.frag_coord - u.resolution * 0.5, 80.0);
    let mask = smoothstep(1.0, -1.0, d);
    // crosshair: union of two axis-aligned line bands
    // Crosshair as the union of two capped line segments (a "+"). Each
    // segment is the SDF of an axis-aligned line: max(perpendicular axis,
    // along-axis - arm). `arm_length` controls how far each arm extends
    // from the centre; raise it past your draw area to recover an
    // infinite cross. Same shape as sd_circle: returns raw distance,
    // smoothstep at the call site.
    // Pass position in (dir, tangent) basis as vec2f(n, t) for an
    // angle-aligned cross, or `in.uv - 0.5` for an axis-aligned one.
    // Call as: let d = sd_cross(in.uv - 0.5, 0.005, 0.3);
    //          let mask = 1.0 - smoothstep(0.0, 0.002, d);
    fn sd_cross(p: vec2<f32>, thickness: f32, arm_length: f32) -> f32 {
        let v = max(abs(p.x), abs(p.y) - arm_length);   // vertical arm
        let h = max(abs(p.y), abs(p.x) - arm_length);   // horizontal arm
        return min(v, h) + thickness;
    }
    // SDF of an arrow from origin pointing along v
    // Arrow as union of a rounded shaft + a triangular head.
    // Pure SDF — negative inside, positive outside, like sd_circle.
    // Compose with min() against other SDFs, smoothstep at call site.
    //   p        — pixel position relative to arrow base
    //   v        — arrow vector (direction + length, in same units as p)
    //   shaft_w  — half-width of the shaft
    //   head_len — length of the head along v (base half-width == head_len*0.5)
    // Zero-vector v degenerates to a dot of radius shaft_w at origin.
    // Call as: let d = sd_arrow(p, vec2f(60.0, 0.0), 3.0, 18.0);
    //          let mask = 1.0 - smoothstep(0.0, 1.5, d);
    fn sd_arrow(p: vec2<f32>, v: vec2<f32>, shaft_w: f32, head_len: f32) -> f32 {
        let mag = length(v);
        if (mag < 0.0001) { return length(p) - shaft_w; }
        let dir  = v / mag;
        let perp = vec2<f32>(-dir.y, dir.x);
        let n = dot(p, dir);              // along arrow
        let t = abs(dot(p, perp));        // perpendicular distance
    
        // Shaft: rectangle [0, mag - head_len*0.5] × [-shaft_w, shaft_w]
        let shaft_end = mag - head_len * 0.5;
        let shaft = max(t - shaft_w, max(-n, n - shaft_end));
    
        // Head: isoceles triangle, apex at n=mag, base at n=mag-head_len,
        // base half-width = head_len * 0.5 (45° opening).
        let h_back  = (mag - head_len) - n;      // negative inside (past base)
        let h_taper = t - (mag - n) * 0.5;       // negative inside taper
        let head = max(h_back, h_taper);
    
        return min(shaft, head);
    }
    // SDF of a diamond / rhombus
    // L1-norm distance to an axis-aligned rhombus with half-radii (rx, ry),
    // scaled by min(rx, ry) so the metric is roughly Euclidean near the
    // surface (good enough for smoothstep masking and SDF composition).
    // Negative inside, positive outside.
    // Call as: let d = sd_diamond(p - center, vec2f(0.4, 0.5));
    fn sd_diamond(p: vec2<f32>, radius: vec2<f32>) -> f32 {
        let d = abs(p);
        return (d.x / radius.x + d.y / radius.y - 1.0) * min(radius.x, radius.y);
    }
    // SDF of a chevron (angle bracket ">") via diamond subtraction
    // Chevron = outer rhombus MINUS the same rhombus shifted left.
    // Boolean subtraction in SDF land: `max(outer, -inner)`. The thickness
    // arg controls how far the inner diamond is shifted — wider gap → bolder
    // chevron. Flip the sign of `thickness` to mirror the chevron (`<`).
    // Requires `sd_diamond` above.
    // Call as: let d = sd_chevron(p, vec2f(0.4, 0.5), 0.2);
    //          let mask = 1.0 - smoothstep(0.0, 0.01, d);
    fn sd_chevron(p: vec2<f32>, radius: vec2<f32>, thickness: f32) -> f32 {
        let outer = sd_diamond(p, radius);
        let inner = sd_diamond(p + vec2<f32>(thickness, 0.0), radius);
        return max(outer, -inner);
    }

    Debug

    Debug visualisation. Most shader quantities — a signed distance, a 2D displacement, a gradient, a surface normal — are invisible in the final image until you map them onto colour. These helpers draw an otherwise-hidden value so you can see what the maths is doing. Gate them behind a checkbox param and remove (or hide) before shipping a gpuppy.

    // debug viz: sign as colour, magnitude as luma
    // Visualise a signed scalar: pick `neg` or `pos` tint by sign, modulate
    // by |v|. Brightness = magnitude, so 0 stays black. `select(a, b, cond)`
    // is WGSL's branchless if-else — returns `b` when cond is true.
    // Call as: debug_signed(value, vec3f(1,0,0), vec3f(1,1,1))
    fn debug_signed(v: f32, neg: vec3<f32>, pos: vec3<f32>) -> vec4<f32> {
        let tint = select(neg, pos, v > 0.0);
        return vec4<f32>(tint * abs(v), 1.0);
    }

    Vector field debug viz. When a shader produces a 2D quantity per pixel (displacement, gradient, flow, normal) it's invisible in the final image until you visualise it directly. The arrow-tile technique below covers the screen with a regular grid of cells (tile size in pixels), samples the field once at each tile centre, and draws a coloured arrow encoding that sample: direction → hue (rotation around the colour wheel via atan2) and magnitude → brightness (length normalised to half-tile). The tile grid implicitly low-passes the field — pick tile_size bigger than the smallest feature you want to read, small enough to see local variation. Render the arrow layer with its own alpha mask and mix on top of your normal output so it stays an overlay, gated behind a debug toggle. Used during development and removed (or hidden behind a checkbox param) before shipping a gpuppy.

    // vector field debug viz — tile of arrows over a 2D field
    // Renders a regular grid of arrows showing a 2D vector field — perfect
    // for debugging displacement / gradient / flow maps. Each tile centre
    // samples your field (`normal` arg) and draws an arrow + colour based
    // on direction (hue) and magnitude (brightness). Requires `cos_palette`
    // from above for the colour ramp.
    //
    // Call as (typically wrapped in a checkbox debug toggle):
    //   let arrows = draw_vector_field(in.frag_coord, u.resolution,
    //                                  u.tile_size, my_displacement_field);
    //   return vec4f(mix(col.rgb, arrows.rgb, arrows.a), col.a);
    const ARROW_HEAD_ANGLE:      f32 = Math.HALF_PI;   // 90° (one quarter turn)
    const ARROW_SHAFT_THICKNESS: f32 = 2.0;
    
    fn arrow_tile_center_coord(pos_px: vec2<f32>, tile_size: f32) -> vec2<f32> {
        return (floor(pos_px / tile_size) + 0.5) * tile_size;
    }
    
    // SDF-like mask for a single arrow inside one tile.
    //   p     — pixel position relative to tile centre
    //   v_px  — arrow vector in pixels (direction + length)
    fn arrow(p: vec2<f32>, v_px: vec2<f32>, tile_size: f32) -> f32 {
        let arrow_head_length = tile_size / 6.0;
        var mag_v = length(v_px);
        let mag_p = length(p);
    
        // Zero-vector: draw a small dot at tile centre instead of an arrow.
        if (mag_v <= 0.0001) {
            return max(0.0, 1.2 - mag_p);
        }
    
        let dir_v = v_px / mag_v;
        mag_v = clamp(mag_v, 5.0, tile_size * 0.5);
        let v = dir_v * mag_v;
    
        let shaft = ARROW_SHAFT_THICKNESS * tile_size * 0.02 -
            max(abs(dot(p, vec2<f32>(dir_v.y, -dir_v.x))),
                abs(dot(p, dir_v)) - mag_v + arrow_head_length * 0.5);
    
        let head_front = min(0.0,
            dot(v - p, dir_v) - cos(ARROW_HEAD_ANGLE * 0.5) * length(v - p)) * 2.0;
        let head_back = min(0.0, dot(p, dir_v) + arrow_head_length - mag_v);
    
        return clamp(1.0 + max(shaft, head_front + head_back), 0.0, 1.0);
    }
    
    // Direction → hue, magnitude → brightness. Zero vector → black.
    fn vector_sine_colormap(v: vec2<f32>, tile_size: f32) -> vec3<f32> {
        let mag = clamp(length(v) / (tile_size * 0.5), 0.0, 1.0);
        let a   = atan2(v.y, v.x);
        return saturate(cos_palette(a * 0.5, 1.0, mag) * mag);
    }
    
    // Compose: snap to tile, draw arrow, colour by direction.
    // Returns vec4f(rgb, mask) — alpha is the arrow occupancy in this pixel.
    fn draw_vector_field(
        frag_coord: vec2<f32>, resolution: vec2<f32>,
        tile_size: f32, normal: vec2<f32>,
    ) -> vec4<f32> {
        let tile_center = arrow_tile_center_coord(frag_coord, tile_size);
        let p           = frag_coord - tile_center;
        let arrow_mask  = saturate(arrow(p, -normal, tile_size));
        let arrow_col   = vector_sine_colormap(-normal, tile_size);
        return vec4<f32>(arrow_col, arrow_mask);
    }

    Blend

    // mix an effect color over the input
    let src = textureSample(input_texture, input_sampler, in.uv);
    let fx = vec3<f32>(1.0, 0.35, 0.08);
    let out_rgb = mix(src.rgb, fx, 0.35);
    return vec4<f32>(out_rgb, src.a);
    // screen blend
    fn screen(a: vec3<f32>, b: vec3<f32>) -> vec3<f32> {
        let one = vec3<f32>(1.0);
        return one - (one - a) * (one - b);
    }
    // overlay blend
    fn overlay_ch(base: f32, blend: f32) -> f32 {
        return select(1.0 - 2.0 * (1.0 - base) * (1.0 - blend), 2.0 * base * blend, base < 0.5);
    }
    
    fn overlay(base: vec3<f32>, blend: vec3<f32>) -> vec3<f32> {
        return vec3<f32>(overlay_ch(base.r, blend.r), overlay_ch(base.g, blend.g), overlay_ch(base.b, blend.b));
    }
    // difference blend
    fn difference(a: vec3<f32>, b: vec3<f32>) -> vec3<f32> {
        return abs(a - b);
    }
    // exclusion blend
    fn exclusion(a: vec3<f32>, b: vec3<f32>) -> vec3<f32> {
        return a + b - 2.0 * a * b;
    }
    // straight-alpha over composite
    fn composite_over(fg: vec4<f32>, bg: vec4<f32>) -> vec4<f32> {
        let a = fg.a + bg.a * (1.0 - fg.a);
        let rgb = (fg.rgb * fg.a + bg.rgb * bg.a * (1.0 - fg.a)) / max(a, Math.EPS);
        return vec4<f32>(rgb, a);
    }
    // over usage
    let out_col = composite_over(vec4<f32>(fx_rgb, fx_alpha), src);
    // matte cutout / source-out
    fn source_out(src: vec4<f32>, matte_alpha: f32) -> vec4<f32> {
        return vec4<f32>(src.rgb, src.a * (1.0 - saturate(matte_alpha)));
    }
    // source-in matte
    fn source_in(src: vec4<f32>, matte_alpha: f32) -> vec4<f32> {
        return vec4<f32>(src.rgb, src.a * saturate(matte_alpha));
    }

    Raymarch

    // camera ray from aspect-correct uv
    let p = (in.uv - 0.5) * 2.0 * vec2<f32>(u.ratio, 1.0);
    let ro = vec3<f32>(0.0, 0.0, -3.0);
    let rd = normalize(vec3<f32>(p, 1.5));
    // Schlick Fresnel
    fn fresnel_schlick(cos_theta: f32, f0: f32) -> f32 {
        return f0 + (1.0 - f0) * pow(1.0 - cos_theta, 5.0);
    }